<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>AI 角色扮演对话</title>
  <style>
    :root { --bg:#0f172a; --panel:#111827; --text:#e5e7eb; --muted:#9ca3af; --primary:#6366f1; }
    * { box-sizing: border-box; }
    body { margin: 0; background: linear-gradient(180deg,#0b1020,#0e152c); font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; color: var(--text); }
    header { padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; background: rgba(17,24,39,.6); backdrop-filter: blur(10px); position: sticky; top: 0; border-bottom: 1px solid #1f2937; }
    .brand { font-weight: 700; letter-spacing: .5px; }
    .container { max-width: 1000px; margin: 24px auto; padding: 0 16px; }
    .panel { background: rgba(17,24,39,.7); border: 1px solid #1f2937; border-radius: 16px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.2); }
    .roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
    .role { padding: 12px; border-radius: 12px; border: 1px solid #374151; cursor: pointer; background: #0b1223; transition: .2s; }
    .role:hover { border-color: var(--primary); transform: translateY(-2px); }
    .role.active { outline: 2px solid var(--primary); }
    .chat { margin-top: 16px; height: 520px; display: grid; grid-template-rows: 1fr auto; gap: 12px; }
    .messages { overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; }
    .msg { max-width: 80%; padding: 10px 12px; border-radius: 12px; line-height: 1.5; }
    .msg.user { align-self: flex-end; background: #1f2937; }
    .msg.ai { align-self: flex-start; background: #0f172a; border: 1px solid #1f2937; }
    .composer { display: flex; gap: 8px; }
    .composer input { flex: 1; padding: 12px; border-radius: 10px; border: 1px solid #374151; background: #0b1223; color: var(--text); }
    .composer button { padding: 10px 14px; border-radius: 10px; background: var(--primary); color: white; border: none; cursor: pointer; }
    .hint { color: var(--muted); font-size: 12px; }
  </style>
</head>
<body>
  <header>
    <div class="brand">RolePlay.AI</div>
  </header>
  <div class="container">
    <div class="panel">
      <div class="hint">选择一个角色开始对话：</div>
      <div class="roles" id="roles"></div>
    </div>

    <div class="panel chat">
      <div class="messages" id="messages"></div>
      <div class="composer">
        <input id="text" placeholder="按回车发送，或点击麦克风说话" />
        <button id="mic">🎙 说话</button>
        <button id="speak">🔊 朗读</button>
      </div>
    </div>
  </div>

  <script>
    const roles = [
      { key: 'harry_potter', name: '喜羊羊' },
      { key: 'socrates', name: '灰太狼' },
      { key: 'sherlock', name: '光头强' },
    ];

    let currentRole = roles[0].key;
    let history = [];

    const $roles = document.getElementById('roles');
    const $messages = document.getElementById('messages');
    const $text = document.getElementById('text');
    const $speak = document.getElementById('speak');
    const $mic = document.getElementById('mic');

    function renderRoles() {
      $roles.innerHTML = roles.map(r => `
        <div class="role ${r.key===currentRole?'active':''}" data-key="${r.key}">${r.name}</div>
      `).join('');
      [...$roles.children].forEach(el => {
        el.addEventListener('click', () => {
          currentRole = el.dataset.key; history = []; $messages.innerHTML='';
          pushAI(`你已切换到【${roles.find(x=>x.key===currentRole).name}】。`);
        });
      });
    }

    function pushUser(text) {
      const div = document.createElement('div');
      div.className = 'msg user';
      div.textContent = text;
      $messages.appendChild(div);
      $messages.scrollTop = $messages.scrollHeight;
    }
    function pushAI(text) {
      const div = document.createElement('div');
      div.className = 'msg ai';
      div.textContent = text;
      $messages.appendChild(div);
      $messages.scrollTop = $messages.scrollHeight;
    }

    async function send(text) {
      if (!text.trim()) return;
      pushUser(text);
      history.push({ role: 'user', content: text });
      $text.value='';
      const resp = await fetch('/api/chat', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ roleKey: currentRole, messages: history })
      });
      const data = await resp.json();
      const reply = data.reply || '（无回复）';
      pushAI(reply + (data.citation? `\n提示：${data.citation}` : ''));
      history.push({ role: 'assistant', content: reply });
      $speak.onclick = () => speak(reply, data.prosody || 'neutral');
      // 自动朗读
      speak(reply, data.prosody || 'neutral');
    }

    async function speak(text, prosody) {
      try {
        const resp = await fetch('/api/tts', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text, roleKey: currentRole, prosody })
        });
        if (!resp.ok) {
          const err = await resp.json().catch(()=>({error:'unknown'}));
          throw new Error(err.error || 'TTS 请求失败');
        }
        const blob = await resp.blob();
        const url = URL.createObjectURL(blob);
        const audio = new Audio(url);
        audio.play();
      } catch (e) {
        alert('TTS 播放失败：' + e.message);
      }
    }

    // 录音实现：WebAudio 采集 -> 重采样到16k -> WAV编码 -> POST /api/asr
    let rec = null;
    let isRecording = false;

    $mic.addEventListener('click', async () => {
      try {
        if (!isRecording) {
          rec = await Recorder.create();
          isRecording = true;
          $mic.textContent = '■ 停止';
          await rec.start();
        } else {
          const wavBlob = await rec.stopAndExportWav();
          isRecording = false;
          $mic.textContent = '🎙 说话';
          // 发送到 ASR
          const asrResp = await fetch('/api/asr', {
            method: 'POST',
            headers: { 'Content-Type': 'application/octet-stream' },
            body: wavBlob
          });
          const asrData = await asrResp.json().catch(()=>({error:'解析失败'}));
          if (!asrResp.ok) throw new Error(asrData.error || 'ASR 失败');
          const text = (asrData.text || '').trim();
          if (text) {
            send(text);
          } else {
            pushAI('（未识别到有效语音）');
          }
        }
      } catch (e) {
        isRecording = false;
        $mic.textContent = '🎙 说话';
        alert('录音失败：' + e.message);
      }
    });

    class Recorder {
      static async create() {
        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        const ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 });
        const source = ctx.createMediaStreamSource(stream);
        const processor = ctx.createScriptProcessor(4096, 1, 1);
        const rec = new Recorder(ctx, source, processor, stream);
        return rec;
      }
      constructor(ctx, source, processor, stream) {
        this.ctx = ctx; this.source = source; this.processor = processor; this.stream = stream;
        this.buffers = []; // Float32 chunks
        this.sampleRate = ctx.sampleRate; // usually 48000
        this.processor.onaudioprocess = (e) => {
          const input = e.inputBuffer.getChannelData(0);
          this.buffers.push(new Float32Array(input));
        };
      }
      async start() {
        this.source.connect(this.processor);
        this.processor.connect(this.ctx.destination);
      }
      async stopAndExportWav() {
        this.processor.disconnect();
        this.source.disconnect();
        this.stream.getTracks().forEach(t => t.stop());
        // 合并
        let length = this.buffers.reduce((sum, b) => sum + b.length, 0);
        const data = new Float32Array(length);
        let offset = 0;
        for (const b of this.buffers) { data.set(b, offset); offset += b.length; }
        // 重采样到 16000 Hz
        const targetRate = 16000;
        const resampled = resampleFloat32(data, this.sampleRate, targetRate);
        // PCM16 编码
        const pcm16 = floatTo16BitPCM(resampled);
        // 封装 WAV
        const wavBuffer = encodeWAV(pcm16, targetRate, 1);
        return new Blob([wavBuffer], { type: 'audio/wav' });
      }
    }

    function resampleFloat32(data, srcRate, dstRate) {
      if (srcRate === dstRate) return data;
      const ratio = srcRate / dstRate;
      const newLen = Math.round(data.length / ratio);
      const result = new Float32Array(newLen);
      let pos = 0;
      for (let i = 0; i < newLen; i++) {
        const idx = i * ratio;
        const i0 = Math.floor(idx);
        const i1 = Math.min(i0 + 1, data.length - 1);
        const t = idx - i0;
        result[i] = data[i0] * (1 - t) + data[i1] * t; // 线性插值
      }
      return result;
    }

    function floatTo16BitPCM(float32Array) {
      const buffer = new ArrayBuffer(float32Array.length * 2);
      const view = new DataView(buffer);
      let offset = 0;
      for (let i = 0; i < float32Array.length; i++, offset += 2) {
        let s = Math.max(-1, Math.min(1, float32Array[i]));
        view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
      }
      return new Uint8Array(buffer);
    }

    function encodeWAV(pcm16, sampleRate, numChannels) {
      const buffer = new ArrayBuffer(44 + pcm16.length);
      const view = new DataView(buffer);
      // RIFF chunk descriptor
      writeString(view, 0, 'RIFF');
      view.setUint32(4, 36 + pcm16.length, true);
      writeString(view, 8, 'WAVE');
      // fmt sub-chunk
      writeString(view, 12, 'fmt ');
      view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM)
      view.setUint16(20, 1, true);  // AudioFormat (1 for PCM)
      view.setUint16(22, numChannels, true);
      view.setUint32(24, sampleRate, true);
      const byteRate = sampleRate * numChannels * 2;
      view.setUint32(28, byteRate, true);
      view.setUint16(32, numChannels * 2, true); // BlockAlign
      view.setUint16(34, 16, true); // BitsPerSample
      // data sub-chunk
      writeString(view, 36, 'data');
      view.setUint32(40, pcm16.length, true);
      // PCM samples
      new Uint8Array(buffer, 44).set(pcm16);
      return buffer;
    }
    function writeString(view, offset, string) {
      for (let i = 0; i < string.length; i++) {
        view.setUint8(offset + i, string.charCodeAt(i));
      }
    }

    $text.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') send($text.value);
    });

    renderRoles();
    pushAI('你好！选择上方的角色开始对话吧。');
  </script>
</body>
</html>